3.1 内存分配与栈帧管理
本节是本章后续学习的基础,在本节我们将会针对内存分配、栈与堆、栈桢等内容进行讲解。
后续的内存逃逸分析、垃圾回收、并发安全等都会与本节密切关联,所以这一节需要我们进行一个详细的了解。
本节代码存放目录为 lesson7
内存分配策略概述
内存分配是一个重要的概念,在编程语言中都会存在这个东西,本质上就是分配主机的内存。
程序执行时在主机内存上分配一些空间,用于存储代码执行中的数据,所以才会出现内存升高、占用异常等等问题。
什么是内存分配
内存分配主要分为两种:静态内存分配和动态内存分配。在之前的章节中,我们讲解了各种类型在内存上的分配规则和内存表示。
当变量被使用时,例如我为一个变量赋值1
,这时实际上是在操作内存,因为变量需要首先分配内存空间才能存储和处理数据,否则操作将会导致异常,这也正是空指针错误产生的原因。
之前我们提到过,Go
程序在编译后会生成一系列的指令集和机器码。在程序启动时,这些机器码会被加载到内存中的代码段中。
总结下来,内存中会存储两类内容:代码段和运行时数据。
如果你熟悉C
语言,可能会知道手动管理内存是一个关键点,程序员需要显式地分配和释放内存。
而在Go
语言中,大部分内存管理是自动化的,不需要程序员手动处理。
需要注意的是,引用类型(如切片、映射、通道)的初始化通常需要使用make
函数,这实际上就是在进行内存分配。
静态内存分配 vs 动态内存分配
静态内存分配:在程序编译时就已经确定了内存需求。比如,var a int
,编译器在编译时就可以计算出a
需要的内存大小。在程序运行时,这部分内存会根据编译时计算的结果直接分配。
动态内存分配:对于map
、chan
、切片
等类型,由于它们的大小在编译时无法确定,只能在程序运行时根据实际需求进行内存分配。动态内存分配通常通过make
或new
等操作完成,涉及到内存的分配、可能的扩容和释放。
从上述概念可以明显看出,静态分配是确定的,而动态分配是不确定的。
由于动态分配需要在运行时进行内存计算和管理,通常会比静态分配多一个步骤,这可能导致额外的性能开销和复杂性。
因此,在执行效率和安全性上,静态内存分配通常比动态内存分配更为稳健。
静态分配由于内存布局在编译时已经确定,减少了运行时的管理开销和潜在的错误风险,而动态分配则需要更复杂的内存管理,可能带来更多的不确定性。
栈与堆的区别
首先我们需要明确一点,不管是栈还是堆,它们都是内存的一部分,这一点在计算机系统中是被普遍理解的。
不同的是,操作系统使用不同的数据结构和内存管理方式来区分和管理栈与堆这两个概念,形成了不同的内存分配和管理策略。
栈内存
栈内存是计算机内存中的一个区域,用于管理函数调用的上下文信息。栈的结构是后进先出(LIFO),这意味着最后被放入栈中的数据将最先被取出。
特点
自动分配和释放:栈内存是由操作系统自动管理的。当一个函数被调用时,系统会在栈上分配一个栈帧,栈帧用于存储函数的局部变量、参数和返回地址。
当函数执行完毕并返回时,栈帧会自动释放,栈指针
(SP)
回到函数调用之前的位置。快速访问:栈内存的分配和释放非常高效,只需移动栈指针即可。由于栈内存是连续的,访问速度极快,这使得栈非常适合用于存储临时数据和短生命周期的变量。
空间有限:栈的大小通常是有限的,由操作系统或编译器预先设定。过大的局部变量或递归过深的函数调用可能导致栈溢出
(stack overflow)
,从而引发程序崩溃。
用途
函数调用的上下文管理:栈主要用于管理函数调用的上下文信息,包括函数的局部变量、参数和返回地址。这些信息存储在栈帧中,当函数调用链很深时,栈上会存在多个栈帧。
局部变量的存储:函数内部声明的局部变量存储在栈上。这些变量的生命周期与函数的生命周期一致,函数返回后,栈帧被销毁,局部变量的内存也被释放。
堆内存
堆内存是计算机内存中一个灵活而复杂的区域,用于动态分配内存。堆的内存管理比栈更复杂,但它提供了更大的灵活性和更长的生命周期。
特点
手动分配和释放:堆内存的分配和释放通常是由我们在代码中显式管理的,在
Go
语言中则由垃圾回收器(GC)
自动处理。程序可以在运行时动态地向堆申请内存,并在不需要时释放内存。
灵活性高,适合存储大对象:堆内存可以动态增长,适合存储大小不确定或生命周期较长的对象,例如常量、全局的通道等。
堆内存的灵活性高,但管理也相对复杂,可能涉及内存碎片化的问题。
访问速度较慢:由于堆内存是动态分配且可能是非连续的,访问堆内存通常比栈内存慢。
用途
动态内存分配:堆内存常用于动态内存分配,比如在程序运行时需要分配内存的对象或数据结构。这些对象的大小和数量通常在编译时不确定。
长生命周期对象的存储:堆内存适合存储生命周期超出函数调用范围的对象,比如返回给调用者的对象,或者跨越多个函数调用的共享数据。
栈与堆的比较
栈内存:
- 优点:
- 分配和释放速度快,管理简单,系统级处理。
- 适合存储短期存在的数据,如局部变量和函数调用的上下文。
- 缺点:
- 空间有限,容易栈溢出。
- 不适合存储需要在多个函数间共享或具有较长生命周期的数据。
堆内存:
- 优点:
- 空间相对无限,适合存储大对象和长生命周期对象。
- 灵活性高,可以在运行时动态分配和调整大小。
- 缺点:
- 分配和释放速度慢,且需要手动管理(在
Go
语言中由GC
自动处理)。 - 容易导致内存泄漏和碎片化问题,管理复杂。
- 分配和释放速度慢,且需要手动管理(在
使用场景的比较
- 栈适合管理函数调用的局部变量、临时数据等生命周期短的数据。
- 堆适合管理动态创建的对象、需要跨函数或线程共享的数据、以及生命周期长的数据。
我们以下面的代码示例进行理解:
const (
aa int = 100
)
func main() {
var a int = 1
fmt.Printf("a: %d\n", a)
}
在上面的代码中,aa
作为一个常量会长期存在,也就是说它是一个长生命周期的对象,所以aa
会被分配到堆上。
a
作为一个临时变量,使用过后就不会再有调用需求,作为一个局部变量,将分配到栈上。
栈帧的构成与管理
什么是栈帧?
我们可以把栈帧想象成一块专门为函数准备的小工作台
或者记事本
。
也可以简单理解为栈桢就是栈的一个单元,是栈的一小部分。
每当一个函数被调用时,系统都会为这个函数准备一个栈帧,用来存放这个函数所需要的一切信息——函数的参数、局部变量、以及在函数结束后应该返回的位置。
栈帧的作用
假设你要做一道菜,每做一道菜时你需要一张新的菜谱纸和一个空碗(栈帧)。
你可以在这张纸上写下步骤(局部变量),在碗里准备食材(函数参数),做完这道菜后,你把这张纸丢掉(栈帧销毁),而碗也清空了(变量消失了)。
如果你中途需要去做另一道菜,你会暂时搁置这张纸和碗,去新拿一张纸和一个碗(新栈帧),做完再回来继续。
栈帧的具体内容
函数参数:就像是你在碗里准备的食材。在函数开始前,这些参数会被
放进
栈帧中,供函数使用。局部变量:在函数执行过程中,你可能需要暂存一些数据,这些数据就像你在纸上写的步骤或中间结果,它们也存放在栈帧里。
返回地址:当你做完一道菜(函数)后,你需要知道接下来要做什么,这就是返回地址。返回地址存放在栈帧中,用来指示函数执行完后,程序应该回到哪里继续运行。
保存的寄存器:有些重要的工具(寄存器)在开始做菜前要先记下位置,以便做完菜后恢复原样。这些工具的状态也会暂时保存在栈帧里。
栈帧的生命周期
创建栈帧:当你决定开始做一道菜(调用函数)时,系统会为你分配一张新纸和一个空碗(创建栈帧)。这一步是非常快的,因为只需向栈中分配一块连续的内存区域。
栈帧的使用:在你做菜的过程中(函数运行),你会不断地在这张纸上写东西(使用局部变量),从碗里取食材(使用参数),直到菜做好(函数执行完毕)。
销毁栈帧:菜做好了(函数结束),你不再需要这张纸和碗(栈帧),于是它们被丢弃或清空(栈帧被销毁)。栈指针(栈顶位置的标记)也会恢复到菜谱开始前的位置,这样下一次再调用函数时,栈可以继续使用。
栈帧在递归中的表现
假设你在做一道菜的过程中,发现需要暂时去做另一道菜(递归调用)。
每次递归调用,就像是在原来的栈帧上新建一层(新的栈帧)。
如果递归次数过多,你的“栈”可能堆得很高,导致“溢出”(栈空间不足)。
总的来说,栈帧就是程序在调用函数时,操作系统为函数专门分配的一小块内存,用来存放该函数运行时所需的所有信息。
函数运行完毕,栈帧销毁,这一切像是一个临时的、自动化的小记事本,帮助函数完成工作而不需要程序员手动管理。
为了方便理解,我们以下面的代码为例:
func main() {
var a int = 1
fmt.Printf("a: %d\n", a)
result := add(a, 20)
fmt.Printf("sum: %d\n", result)
}
func add(x, y int) int {
sum := x + y
return sum
}
当main
函数开始执行时,栈中创建了一个栈帧,用来存储a
变量以及main
函数的相关数据。
当main
函数调用add
函数时,栈上又创建了一个新的栈帧,用来存储add
函数的参数x
和y
、局部变量sum
以及add
函数执行完后需要返回的地址。
当add
函数执行完毕返回时,它的栈帧被销毁,栈指针回到main
函数的栈帧位置。
实例讲解栈指针
在上文我们提到过一个概念栈指针SP
,那么这是什么东西呢?
栈指针我们可以将它理解为:它就是栈上的一个游标,通过滑动游标可以进行栈的扩大、减小。
我们以游标卡尺测量进行一个举例说明:
主尺与栈:在游标卡尺中,主尺显示整体的测量范围,对应于整个栈的内存空间。主尺上的刻度对应于栈中的每个位置。
游标与栈指针:游标可以在主尺上滑动,指示当前测量的位置。这就像栈指针在栈上滑动,指向当前栈顶的位置。
当游标滑动时,它会覆盖或显示不同的刻度,类似于栈指针移动时管理不同的栈帧。
测量过程与栈帧操作:当你测量一个物体时,游标滑动到适当的位置,表示你确定了测量值。
这类似于函数调用时,栈指针滑动到新的栈帧位置,保存了函数调用的信息(参数、局部变量等)。
测量完成后,你可以将游标移回初始位置,清除测量值。
类似地,函数执行完毕后,栈指针回退到之前的位置,栈帧被销毁。
精确度与管理栈帧:游标卡尺通过精确的刻度和滑动机制,确保测量的准确性。
栈指针通过精确的移动,确保函数调用和返回的顺序正确,并且每个栈帧都被正确地管理。
从上面的举例中,为我们应该可以大概的理解了栈指针到底是怎么工作的。
简单的来说就是:栈指针一直指向栈顶,就意味着栈指针滑动可以对栈进行扩充、缩小,也就是通过扩充、缩小实现了资源创建、回收、
我们可以结合函数看一下示意:
初始状态:SP指向栈顶
+-------+
| | <- SP (栈顶)
+-------+
| |
| |
| |
- 函数
A
调用:SP
移动,为函数A创建栈帧
函数A栈帧:
+-------+
| A's | <- SP (新栈顶)
| Data |
+-------+
| |
| |
| |
- 函数B调用:
SP
继续移动,为函数B
创建栈帧
函数B栈帧:
+-------+
| B's | <- SP (新栈顶)
| Data |
+-------+
| A's |
| Data |
+-------+
| |
| |
函数B返回:SP
回退,恢复到函数A
的栈帧
函数A栈帧:
+-------+
| A's | <- SP (恢复到A的栈顶)
| Data |
+-------+
| |
| |
| |
从示意中我们可以看出,栈指针通过移动实现了对函数调用的管理,确保函数调用和返回的顺序被正确地执行。
同时从上面的示意我们也可以猜想到栈溢出的原因。如果进入栈的数据特别多,那么SP
一直移动,最终移动到栈外面去了,这时候就发生了栈溢出。